昨天講完資料模型設計為什麼重要,也講完修改的風險怎麼分級。今天,終於輪到那些一開始讓我頭皮發麻的摩斯密碼了。
剛打開 BuJo 資料庫的時候,我完全看不懂那些方框跟線在畫什麼。方框裡的縮寫、線的兩端、還有那些長得幾乎一模一樣的小鑰匙圖示,看起來就是一團工程世界的亂碼。
但後來我發現,那張圖其實一點都不隨便——它有一套固定的畫法,只是從來沒有人告訴過我。
工具可以把圖生成給你,卻不會順便解釋圖上每個記號代表什麼。
所以今天,我想從最前面開始:
先搞懂這張圖到底在畫什麼。
關聯式資料庫(Relational Database),簡單說,就是把資料拆成一張一張的 資料表(Table),再透過彼此之間的 關聯(Relationship) 把它們連起來,而不是把所有資訊全部塞在同一個地方。
資料庫會把不同類型的資料分開存放。例如,使用者、活動和通知,會分別放在不同的 資料表(Table) 中。
每張資料表都會定義自己需要記錄的欄位(Column),而每個欄位還會設定資料型別(Data Type),用來限制它可以存放哪一種資料。
不同資料表之間,則會透過像 外鍵(Foreign Key) 這類機制建立彼此的對應關係。
如果把這些結構畫成圖,就會像下面這樣:

先不用急著把圖上的每個符號都看懂,現在只要知道怎麼讀這張圖就好:
id、user_id、is_read
uuid、text、datetime 是資料型別(Data Type)
至於圖上另外標出的 ①~④,才是接下來兩天要拆開介紹的四個核心概念:
| 是什麼 | 白話說 | |
|---|---|---|
| ① 實體 Entity | 這個系統要記住哪些「東西」 | 我的產品世界裡有哪些角色 |
| ② 約束 Constraint | 資料庫自己負責把關的規則 | 什麼事絕對不准發生 |
| ③ 關聯基數 Cardinality | 一邊的一筆,可以對應到另一邊多少筆 | 這些角色之間是幾對幾 |
| ④ 索引 Index | 讓查詢跑得更快的機制 | 找東西要不要翻遍整本書 |
上面那張圖是視覺化的結果。
而如果把同一份結構換成資料庫真正會執行的 SQL(Structured Query Language),可以長這樣:
CREATE TABLE notifications /* ① */ (
id UUID PRIMARY KEY /* ② */
DEFAULT gen_random_uuid() /* ③ */,
user_id UUID /* ④ */
NOT NULL /* ⑤ */,
is_read BOOLEAN /* ④ */
NOT NULL /* ⑤ */
DEFAULT false,
FOREIGN KEY (user_id)
REFERENCES users(id) /* ⑥ */
ON DELETE CASCADE /* ⑦ */
);
CREATE INDEX /* ⑧ */ idx_notifications_user_read
ON notifications (user_id, is_read);
| 位置 | 對應語法 | 對應概念 |
|---|---|---|
| ① | notifications |
資料表(Table)——用來承載 Notification 這個 實體(Entity) 的資料 |
| ② | PRIMARY KEY |
主鍵(Primary Key, PK) |
| ③ | DEFAULT gen_random_uuid() |
預設值(Default Value) |
| ④ | UUID、BOOLEAN |
資料型別(Data Type) |
| ⑤ | NOT NULL |
非空約束(NOT NULL Constraint) |
| ⑥ | FOREIGN KEY … REFERENCES users(id) |
外鍵(Foreign Key, FK),建立資料表之間的關聯(Relationship) |
| ⑦ | ON DELETE CASCADE |
參照動作(Referential Action) |
| ⑧ | CREATE INDEX |
索引(Index) |
我要建立一張
notifications表。每筆通知都有自己的主鍵
id,沒給值時就自動產生 UUID。
user_id一定要指向真的存在的使用者;使用者被刪掉時,他的通知也一起刪掉。
is_read不可以留空,預設是false。最後替
user_id + is_read建一個索引,讓「查某個人的未讀通知」這類查詢更快。
這張表就是這兩天的詞彙表。
原本我以為,把這張圖上的符號一次解完應該不難。
但真的開始為了這篇文章查資料、重新把每個概念拆開來看之後,我才發現,資料庫裡每一個看起來很小的符號,背後其實都有很多值得繼續深入研究的東西。
全部塞在同一天,反而很容易每個都只講到一點點。
所以這一關我決定拆成兩篇。
今天先講實體(Entity)和關聯基數(Cardinality);明天再繼續拆約束(Constraint)和索引(Index)。
實體(Entity),可以先理解成「系統裡需要被獨立記錄、有自己身分的一個對象」。
那怎麼知道有哪些實體?
實務上有一個很直覺的起手式,可以從 名詞短語分析(Noun Phrase Analysis) 開始——把產品的需求描述念一遍,把裡面的名詞全部圈出來,那些名詞就可以先列為實體的候選人。
「使用者可以建立活動,活動可以有多個候選時段,其他參加者對候選時段回報空閒時間,系統會發送通知。」
圈出來之後,再對每一個問幾個問題:
這個東西會不會重複出現很多次?它自己有沒有專屬的屬性?它需不需要被獨立識別或被其他資料參照?
如果答案大多是「有」,它就很值得被列入候選實體(Candidate Entity),再進一步判斷是不是需要獨立建模。
反過來說,如果你在某張表上看到 xxx1、xxx2、xxx3 這種重複編號的欄位,通常就是一個值得警覺的 Schema Smell。
這種寫法有個正式名稱,叫 重複群組(Repeating Group),它違反的是資料庫設計裡的 第一正規化(First Normal Form, 1NF) ——同一類資料不應該靠編號欄位橫向長出去,而應該直向存成一筆一筆的資料列。
換句話說,這組資料多半更適合拆成另一張表。
BuJo 的 User、Activity、Notification 都是獨立的實體,因為它們各自都有很多筆、也各自有專屬的屬性。
以 User 為例:
CREATE TABLE users (
id UUID PRIMARY KEY,
display_name TEXT NOT NULL,
avatar_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
在資料模型裡,我們把「使用者」視為一個 實體(Entity);到了關聯式資料庫裡,則用 users 這張 資料表(Table) 來保存它的資料。
id、display_name、avatar_url、created_at 則是資料表裡的 欄位(Column),對應到這個實體需要保存的屬性。
關聯基數(Cardinality),描述的是兩個實體之間,一邊的一筆資料可以對應到另一邊多少筆資料。
這篇先從最常見的三種關係型態開始看:
最常見的一種。
一個活動可以開很多個候選時段,但每一個候選時段只屬於一個活動。
典型的做法,是把外鍵放在「多」的那一邊:
CREATE TABLE candidate_slots (
id UUID PRIMARY KEY,
activity_id UUID NOT NULL,
FOREIGN KEY (activity_id)
REFERENCES activities(id)
);
每一筆候選時段只會記一個 activity_id,所以它只屬於一個活動;但同一個 activity_id 可以出現在很多筆候選時段裡。
因此形成:
Activity 1 → N CandidateSlots
也就是在 1:N 的關係裡,外鍵通常會放在 N 的那一端,讓每一筆資料記住「我屬於誰」。
1:1 常見的一種實作方式,是在外鍵上再加一條 唯一性約束(Unique Constraint),讓同一個對象最多只能被對應一次。
例如活動和排程設定:
CREATE TABLE activity_schedules (
activity_id UUID NOT NULL UNIQUE,
FOREIGN KEY (activity_id)
REFERENCES activities(id)
);
activity_id 一方面記著「這份排程設定屬於哪一個活動」,另一方面又有 UNIQUE,所以同一個活動最多只能在這張表裡出現一次。
也就是:
一個活動最多只會對應到一份排程設定。
這裡要注意的是,這個結構保證的是「最多一份」,但它沒有保證「一定有一份」。
這件事在 ER 模型裡也有自己的名字,叫 參與度(Participation),講的是最小基數——這一邊的資料,是「一定要」有對應的對象,還是「可以沒有」也沒關係。
UNIQUE 管的是最多幾筆,參與度管的是最少幾筆。
至於每個活動是不是一定都要有一份排程設定,就要看產品本身的 業務規則(Business Rule) 怎麼定了。
一個使用者可以參加很多活動,一個活動也可以有很多參加者——兩邊都是「多」。
在關聯式資料庫裡,這種關係通常會用一張 中介表(Join Table) 來記錄兩邊的配對。
BuJo 的做法可以簡化成:
CREATE TABLE activity_participants (
activity_id UUID NOT NULL,
user_id UUID NOT NULL,
status TEXT NOT NULL,
FOREIGN KEY (activity_id)
REFERENCES activities(id),
FOREIGN KEY (user_id)
REFERENCES users(id),
UNIQUE (activity_id, user_id)
);
每一筆資料都代表:
「某個人參加了某個活動。」
activity_id 指向活動,user_id 指向使用者。
而像 status 這種欄位,描述的不是使用者、也不是活動,而是這一段參加關係本身,所以也最適合放在中介表裡。
最後:
UNIQUE (activity_id, user_id)
則限制同一個人和同一個活動的組合,只能有一筆參加紀錄。
| 基數 | BuJo 的例子 | 常見做法 |
|---|---|---|
| 1:N | 活動 → 候選時段 | 在候選時段表放 activity_id 外鍵 |
| 1:1 | 活動 ↔ 排程設定 | 外鍵再加 UNIQUE |
| M:N | 使用者 ↔ 活動 | 用中介表 activity_participants 承接 |
還有一件事值得記著:
昨天那張「一旦錯了會很貴」的清單裡,關聯基數就在上面。
從 1:N 改成 M:N,通常代表要新建一張中介表、修改原本的關聯方式,還可能要搬資料。
反過來,如果原本一筆資料可以對應很多個對象,後來卻改成只能留一個,就得重新決定:
原本那些多出來的關係,到底該怎麼處理?
所以基數不是隨手決定的東西。
它是那種:
現在多想十分鐘,之後省下三天。
到這裡,這張圖上的方框跟線,我大概都能講出它們在說什麼了——有哪些角色、彼此是幾對幾、哪些東西該獨立成一張表。
但一開始讓我瞇著眼睛看半天的那些 PK、FK、NOT NULL、UNIQUE,還沒有真正拆開。
而且我後來發現,還有很多重要規則不是只看方框和線就能知道:
這個欄位可不可以空著?
同一組資料能不能重複?
活動被刪掉的時候,底下的資料要一起消失嗎?
為什麼有些查詢會突然變慢?
前面那張詞彙表裡的 ②PRIMARY KEY、⑤NOT NULL、⑦ON DELETE CASCADE、⑧CREATE INDEX,也都還在等著解鎖。
明天,就來拆開 約束(Constraint)和索引(Index):
一個負責守住資料規則,一個負責讓資料更快被找到。
iThome鐵人賽